import { firebaseConfig } from '/auth/firebase-config.js'; import { rewardsConfig } from '/js_v3/rewards-config.js'; import { getApps, getApp, initializeApp } from 'https://www.gstatic.com/firebasejs/12.11.0/firebase-app.js'; import { getAuth, onAuthStateChanged } from 'https://www.gstatic.com/firebasejs/12.11.0/firebase-auth.js'; import * as cardModule from './cards-data.js'; import { missionCatalog } from './missions-data.js'; const app = getApps().length ? getApp() : initializeApp(firebaseConfig); const auth = getAuth(app); const PACKS = [ { id: 'standard', name: 'Sobre básico', price: 5, subtitle: '5 cartas normales', pitch: '41% común · 27% poco común · 18% rara · 10% épica · 4% legendaria.', bullets: ['No salen cartas Plata ni Oro', 'Consume primero MAJ si tienes', 'Las repetidas sirven para fusionar', 'Pequeña posibilidad de vestuario alternativo'], enabled: true }, { id: 'gala', name: 'Sobre de gala', price: 10, subtitle: 'Mejores rarezas + vestuario', pitch: '30% común · 25% poco común · 20% rara · 15% épica · 10% legendaria. 50% de intentar imagen alternativa si el personaje tiene.', bullets: ['No salen Plata/Oro directamente', '50% de vestuario alternativo si existe', 'Consume primero MAJ', 'Más útil para completar colección'], enabled: true }, { id: 'fayenne_promo', name: 'Sobre promocional Fayenne', price: 10, subtitle: '80% personajes del grupo FAYENNE', pitch: '80% del grupo FAYENNE con proporciones básicas. 20% general limitado a común/poco común/rara.', bullets: ['Ideal para buscar personajes de la serie', 'No salen Plata/Oro directamente', 'El 20% general no da épicas/legendarias', 'Consume primero MAJ'], enabled: true }, ]; const RARITY_ORDER = ['Común', 'Poco común', 'Rara', 'Épica', 'Legendaria', 'Especial']; const RARITY_SCORE = { 'Común': 1, 'Poco común': 2, 'Rara': 4, 'Épica': 7, 'Legendaria': 11, 'Especial': 13 }; const VARIANT_SCORE = { 'Normal': 0, 'Plata': 3, 'Oro': 8, 'Especial': 10 }; const CACHE_KEY = 'fayenne_cardgame_cache_v81'; const DECK_KEY = 'fayenne_cardgame_deck_v81'; const $ = (s, root = document) => root.querySelector(s); const $$ = (s, root = document) => Array.from(root.querySelectorAll(s)); const esc = (v = '') => String(v ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const number = v => { const n = Number(v || 0); return Number.isFinite(n) ? n : 0; }; const sum = arr => arr.reduce((a, b) => a + b, 0); const rnd = (min, max) => Math.floor(min + Math.random() * (max - min + 1)); const cap = s => String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1); const norm = s => String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); const slug = s => norm(s).replace(/\s+/g, '-') || 'item'; function rawCardsFromModule() { return cardModule.cardsData || cardModule.cardGameData?.cardsData || cardModule.cardGameData?.cards || cardModule.cards || []; } function rawGroupsFromModule() { return cardModule.groupCatalog || cardModule.cardGameData?.groupCatalog || []; } function rawGroupRulesFromModule() { return cardModule.groupRules || cardModule.cardGameData?.groupRules || []; } function rawGroupBonusRulesFromModule() { return cardModule.groupBonusRules || cardModule.cardGameData?.groupBonusRules || []; } function rawPairRulesFromModule() { return cardModule.pairRules || cardModule.cardGameData?.pairRules || []; } function rawSkinsFromModule() { return cardModule.skinData || cardModule.skins || cardModule.cardGameData?.skinData || cardModule.cardGameData?.skins || []; } function cleanCategory(c) { const v = String(c || 'Normal').trim(); if (/plata/i.test(v)) return 'Plata'; if (/oro/i.test(v)) return 'Oro'; if (/especial/i.test(v)) return 'Especial'; return 'Normal'; } function cardBaseId(c) { return c?.baseId || c?.variantOf || slug(c?.baseName || c?.name || c?.id); } function normalizeCard(c) { const baseId = cardBaseId(c); const category = cleanCategory(c.category || c.variantLabel || c.variantType || 'Normal'); return { ...c, baseId, variantOf: c.variantOf || baseId, baseName: c.baseName || c.name || baseId, category, variantLabel: c.variantLabel || category, rarity: c.rarity || 'Común', groups: Array.isArray(c.groups) ? c.groups.filter(Boolean) : [], packEligible: c.packEligible ?? (category === 'Normal' && !c.isSpecial && c.obtainable !== false), upgradeCost: c.upgradeCost || 5, imageVariants: Array.isArray(c.imageVariants) ? c.imageVariants : [], }; } function normalizeGroup(g) { const name = String(g?.name || g?.group || g || '').trim(); const id = g?.id || slug(name); return { ...(typeof g === 'object' && g ? g : {}), id, name, group: name, icon: g?.icon || `/assets/grupos/${slug(name)}.png`, description: g?.description || '', enabled: g?.enabled !== false }; } function bonusListToEffects(list = [], type = 'Bonus') { const effects = { pools: {}, bonuses: {}, autoSuccesses: {}, wildcardPool: 0, wildcardBonus: 0, groupPV: 0, allSkills: 0 }; if (/penal/i.test(type) && (!list || !list.length)) effects.allSkills -= 1; for (const b of (list || [])) { const kind = String(b.bonusType || b.type || 'directo').toLowerCase(); const section = String(b.section || b.category || '').trim(); const skill = String(b.skill || '').trim(); const value = number(b.value || b.amount || 1) || 1; const key = section && skill ? `${section}.${skill}` : section; if (kind.includes('penal')) effects.allSkills -= Math.abs(value); else if (kind.includes('pull')) effects.pools[section || skill || 'comodin'] = number(effects.pools[section || skill || 'comodin']) + value; else if (kind.includes('exito') || kind.includes('auto')) effects.autoSuccesses[key || section || skill] = number(effects.autoSuccesses[key || section || skill]) + value; else if (kind.includes('comod')) effects.wildcardPool += value; else if (kind.includes('pv')) effects.groupPV += value; else if (key) effects.bonuses[key] = number(effects.bonuses[key]) + value; } return effects; } function normalizeGroupRules() { const direct = rawGroupRulesFromModule(); if (direct.length && direct[0]?.effects) return direct; const source = rawGroupBonusRulesFromModule(); return source.map(r => ({ group: r.group || r.name, rule: r.rule || 'members_minus_one', minMembers: number(r.minMembers || 2), stacking: r.stacking || { directBonuses: 'not_stack_same_bonus', pulls: 'stack_members_minus_one', groupPV: 'shared_pool' }, effects: bonusListToEffects(r.bonuses || [], 'Bonus'), raw: r, })); } function normalizePairRules() { return rawPairRulesFromModule().map(pr => { if (pr.effects && pr.receiver && pr.related) return pr; const receiver = pr.receiver || pr.receiverKey || slug(pr.receiverName || ''); const related = pr.related || pr.with || pr.withKey || slug(pr.withName || ''); return { receiver: slug(receiver), related: slug(related), type: pr.type || 'Bonus', raw: `${pr.receiverName || receiver} - ${pr.withName || related}`, effects: bonusListToEffects(pr.bonuses || [], pr.type || 'Bonus'), original: pr }; }).filter(x => x.receiver && x.related); } function buildSkins(cards) { const out = []; const seen = new Set(); // V6.0: mapa defensivo para detectar imágenes de evolución aunque el editor // las exporte como "Imagen base" o como skin normal. const evolutionImageCategory = new Map(); const evolutionCardByImage = new Map(); for (const raw of cards) { const c = raw || {}; const baseId = c.baseId || c.variantOf || slug(c.baseName || c.name || c.id || ''); const cat = cleanCategory(c.category || c.variantLabel || 'Normal'); if (!baseId || (cat !== 'Plata' && cat !== 'Oro')) continue; const images = [c.image, ...(Array.isArray(c.imageVariants) ? c.imageVariants.map(v => v && v.image) : [])].filter(Boolean); for (const img of images) { const key = `${baseId}::${String(img).trim()}`; evolutionImageCategory.set(key, cat); evolutionCardByImage.set(key, c.id); } } const inferEvolutionCategory = (s = {}, baseId = '') => { const text = `${s.id || ''} ${s.label || ''} ${s.kind || ''} ${s.image || ''} ${s.unlockCategory || ''}`; if (/plata/i.test(text)) return 'Plata'; if (/oro/i.test(text)) return 'Oro'; const byImage = evolutionImageCategory.get(`${baseId}::${String(s.image || '').trim()}`); return byImage || cleanCategory(s.unlockCategory || 'Normal'); }; const addSkin = (s = {}) => { if (!s.image) return; const baseId = s.baseId || s.variantOf || slug(s.baseName || ''); if (!baseId) return; const id = s.id || `${baseId}_${slug(s.label || s.kind || 'imagen')}`; if (seen.has(id)) return; seen.add(id); const unlockCategory = inferEvolutionCategory(s, baseId); const requiresEvolution = unlockCategory === 'Plata' || unlockCategory === 'Oro'; const baseLike = /_normal$|_base$/i.test(id); const imageKey = `${baseId}::${String(s.image || '').trim()}`; out.push({ ...s, id, baseId, label: s.label || 'Imagen', image: s.image, weight: number(s.weight || 1), kind: requiresEvolution ? slug(unlockCategory) : (s.kind || 'vestuario'), default: requiresEvolution ? false : (!!s.default || baseLike), free: requiresEvolution ? false : (!!s.free || !!s.default || baseLike), unlockCategory, unlockedByCardId: s.unlockedByCardId || evolutionCardByImage.get(imageKey), }); }; // 1) Mantiene las skins exportadas por el editor, pero corrige las que sean imágenes de Plata/Oro. for (const s of rawSkinsFromModule()) addSkin(s); // 2) Añade automáticamente las imágenes propias de Normal / Plata / Oro / Especial. for (const c of cards) { const category = cleanCategory(c.category || c.variantLabel || 'Normal'); const categorySlug = slug(category); const isNormal = category === 'Normal'; // Las variantes de vestuario solo se leen de la carta Normal. // Las cartas Plata/Oro solo aportan su imagen principal, para evitar duplicados desbloqueados. if (isNormal) { for (const v of (c.imageVariants || [])) { if (v.enabled === false || !v.image) continue; const localId = v.id || slug(v.label || 'base'); const id = `${c.baseId}_${localId}`; addSkin({ id, baseId: c.baseId, label: v.label || 'Imagen normal', image: v.image, weight: number(v.weight || 1), kind: v.kind || 'base', // V6.1: solo la imagen base/default es gratis. El resto de vestuarios se desbloquean en sobres/eventos/admin. default: v.id === c.defaultImageVariant || v.kind === 'base' || localId === 'base', free: v.kind === 'base' || localId === 'base' || !!v.default, unlockCategory: 'Normal', unlockedByCardId: (v.kind === 'base' || localId === 'base' || !!v.default) ? c.id : '', }); } } if (c.image) { const id = isNormal ? `${c.baseId}_base` : `${c.baseId}_${categorySlug}`; addSkin({ id, baseId: c.baseId, label: isNormal ? 'Imagen normal' : `Imagen ${category}`, image: c.image, weight: isNormal ? 100 : 1, kind: isNormal ? 'base' : categorySlug, default: isNormal, free: isNormal, unlockCategory: category, unlockedByCardId: c.id, }); } } return out; } const cardsData = rawCardsFromModule().map(normalizeCard); const groupCatalog = rawGroupsFromModule().map(normalizeGroup); const groupRules = normalizeGroupRules(); const pairRules = normalizePairRules(); const byId = Object.fromEntries(cardsData.map(c => [c.id, c])); const normalCards = cardsData.filter(c => c.category === 'Normal'); const skinData = buildSkins(cardsData); const skinsById = Object.fromEntries(skinData.map(s => [s.id, s])); const skinsByBase = skinData.reduce((acc, s) => { (acc[s.baseId] ||= []).push(s); return acc; }, {}); const groupsByNorm = Object.fromEntries(groupCatalog.map(g => [norm(g.name), g])); const state = { user: null, wallet: { ma: 0, maFree: 0, maPaid: 0 }, player: { collection: {}, stats: { packsOpened: 0, missionsWon: 0 }, starterClaimed: false, selectedSkins: {}, ownedSkins: {} }, deck: loadDeck(), filters: { rarity: 'all', variant: 'all', owned: 'owned', search: '' }, }; const ADMIN_EMAILS = ['acquimera@gmail.com', 'leyendasdehidros@gmail.com']; function isAdminUser() { return ADMIN_EMAILS.includes(String(state.user?.email || '').toLowerCase()); } // Protección anti-autofill: Chrome/Edge a veces mete el usuario de Gmail en el primer buscador. // Solo aceptamos texto si viene después de una acción real de teclado/pegado. let ccSearchUserTouched = false; let ccSearchProtectionUntil = Date.now() + 2500; function hardResetSearch(reason = '') { const input = document.getElementById('ccSearchInput'); if (!input) return; if (!ccSearchUserTouched) { input.value = ''; state.filters.search = ''; } } function enableSearchManualInput() { const input = document.getElementById('ccSearchInput'); if (!input) return; input.removeAttribute('readonly'); } function apiBase() { return String(rewardsConfig.functionsBaseUrl || '').replace(/\/$/, ''); } function saveCache(payload) { try { localStorage.setItem(CACHE_KEY, JSON.stringify({ t: Date.now(), ...payload })); } catch (_) {} } function loadCache() { try { return JSON.parse(localStorage.getItem(CACHE_KEY) || 'null'); } catch (_) { return null; } } function saveDeck() { try { localStorage.setItem(DECK_KEY, JSON.stringify(state.deck)); } catch (_) {} } function loadDeck() { try { const raw = JSON.parse(localStorage.getItem(DECK_KEY) || 'null'); if (raw?.active && raw?.reserve) return raw; } catch (_) {} return { active: [], reserve: [] }; } function ownedQty(cardId) { return number(state.player.collection?.[cardId]?.qty || state.player.collection?.[cardId] || 0); } function isOwned(cardId) { return ownedQty(cardId) > 0; } function totalOwnedCards() { return sum(Object.values(state.player.collection || {}).map(v => number(v?.qty || v))); } function uniqueOwnedCards() { return Object.keys(state.player.collection || {}).filter(k => ownedQty(k) > 0).length; } function collectionPower() { return cardsData.reduce((acc, c) => acc + ownedQty(c.id) * ((RARITY_SCORE[c.rarity] || 0) + (VARIANT_SCORE[c.category] || 0) + 1), 0); } function baseOf(cardOrId) { const c = typeof cardOrId === 'string' ? byId[cardOrId] : cardOrId; return cardBaseId(c); } function findEvolutionTarget(card) { if (!card) return null; const next = card.category === 'Normal' ? 'Plata' : card.category === 'Plata' ? 'Oro' : null; if (!next) return null; const base = baseOf(card); return cardsData.find(c => baseOf(c) === base && c.category === next) || byId[`${base}_${next.toLowerCase()}`] || byId[`${base}-${next.toLowerCase()}`] || null; } function canEvolve(card) { const target = findEvolutionTarget(card); return !!target && ownedQty(card.id) >= number(target.upgradeCost || 5); } function evolutionLabel(card) { const t = findEvolutionTarget(card); return t ? `Fusionar a ${t.category}` : 'Fusionar'; } const EVOLUTION_CATEGORIES = ['Normal', 'Plata', 'Oro']; function cardByBaseCategory(baseId, category) { const b = String(baseId || ''); return cardsData.find(c => baseOf(c) === b && c.category === category) || null; } function qtyByBaseCategory(baseId, category) { const c = cardByBaseCategory(baseId, category); return c ? ownedQty(c.id) : 0; } function ordinaryCardsForBase(baseId) { return EVOLUTION_CATEGORIES.map(cat => cardByBaseCategory(baseId, cat)).filter(Boolean); } function highestOwnedOrdinaryCard(baseId, includeMissing = false) { for (const cat of ['Oro', 'Plata', 'Normal']) { const c = cardByBaseCategory(baseId, cat); if (c && ownedQty(c.id) > 0) return c; } return includeMissing ? (cardByBaseCategory(baseId, 'Normal') || ordinaryCardsForBase(baseId)[0] || null) : null; } function baseHasAnyOwned(baseId) { return ordinaryCardsForBase(baseId).some(c => ownedQty(c.id) > 0); } function baseEvolutionOptions(baseId) { const normal = cardByBaseCategory(baseId, 'Normal'); const silver = cardByBaseCategory(baseId, 'Plata'); const gold = cardByBaseCategory(baseId, 'Oro'); const out = []; if (normal && silver) { const cost = number(silver.upgradeCost || 5); out.push({ source: normal, target: silver, sourceCategory: 'Normal', targetCategory: 'Plata', cost, available: qtyByBaseCategory(baseId, 'Normal') }); } if (silver && gold) { const cost = number(gold.upgradeCost || 5); out.push({ source: silver, target: gold, sourceCategory: 'Plata', targetCategory: 'Oro', cost, available: qtyByBaseCategory(baseId, 'Plata') }); } return out; } function bestPlayableCardForBase(baseId) { return highestOwnedOrdinaryCard(baseId, true); } function evolutionCounterHtml(baseId) { const parts = EVOLUTION_CATEGORIES.map(cat => { const c = cardByBaseCategory(baseId, cat); if (!c) return ''; const q = ownedQty(c.id); if (!q && cat !== 'Normal') return ''; const cls = cat === 'Oro' ? 'cc-tier-gold' : cat === 'Plata' ? 'cc-tier-silver' : 'cc-tier-normal'; return `${esc(cat)} x${q}`; }).filter(Boolean); return parts.length ? `
${parts.join('')}
` : ''; } function hasOwnedCardForSkin(s = {}) { if (s.unlockedByCardId && ownedQty(s.unlockedByCardId) > 0) return true; if (s.unlockCategory) { const c = cardByBaseCategory(s.baseId, s.unlockCategory); if (c && ownedQty(c.id) > 0) return true; } return false; } function skinRequiresEvolution(s = {}) { const cat = cleanCategory(s.unlockCategory || 'Normal'); const kind = String(s.kind || '').toLowerCase(); return cat === 'Plata' || cat === 'Oro' || kind === 'plata' || kind === 'oro'; } function isSkinOwned(skinId) { const s = skinsById[skinId]; if (!s) return false; // V6.1: las imágenes de evolución se desbloquean por tener Plata/Oro. if (skinRequiresEvolution(s)) return hasOwnedCardForSkin(s); // Las skins normales NO se desbloquean solo por tener la carta. Deben ser base/free o estar ganadas en sobre/evento/admin. return !!state.player.ownedSkins?.[skinId] || !!s.free || !!s.default; } function categorySkinForCard(card) { const cat = cleanCategory(card?.category || 'Normal'); if (!card || cat === 'Normal') return null; const list = skinsByBase[card.baseId] || []; return list.find(s => isSkinOwned(s.id) && (s.unlockedByCardId === card.id || s.unlockCategory === cat || s.kind === slug(cat))); } function resolveImage(card, forcedSkinId = '') { if (!card) return ''; if (forcedSkinId && skinsById[forcedSkinId]) return skinsById[forcedSkinId].image; if (card.category !== 'Especial') { const selected = state.player.selectedSkins?.[card.baseId]; if (selected && isSkinOwned(selected) && skinsById[selected]) return skinsById[selected].image; const byCategory = categorySkinForCard(card); if (byCategory) return byCategory.image; const def = (skinsByBase[card.baseId] || []).find(s => (s.default || s.free) && isSkinOwned(s.id)); if (def) return def.image; } return card.image; } function displayVariant(card) { return card.category || card.variantLabel || card.variantType || 'Normal'; } function groupIcon(name) { return groupsByNorm[norm(name)]?.icon || `/assets/grupos/${slug(name)}.png`; } function renderGroupIcons(groups = [], max = 8) { const list = (groups || []).slice(0, max); if (!list.length) return 'Sin grupos'; return `
${list.map(g => { const initials = String(g || '?').split(/\s+/).map(x=>x[0]).join('').slice(0,3).toUpperCase(); return `${esc(g)}${esc(initials || g)}`; }).join('')}
`; } function normalizeState(data = {}) { state.wallet = { ma: number(data.wallet?.ma), maFree: number(data.wallet?.maFree), maPaid: number(data.wallet?.maPaid), maGame: number(data.wallet?.maGame ?? data.wallet?.maj) }; const p = data.player || {}; state.player = { collection: p.collection || {}, stats: { packsOpened: number(p.stats?.packsOpened), missionsWon: number(p.stats?.missionsWon) }, starterClaimed: !!p.starterClaimed, selectedSkins: p.selectedSkins || {}, ownedSkins: p.ownedSkins || {}, }; if (!state.deck.active.length && uniqueOwnedCards()) autoDeck(false); pruneDeck(); saveCache({ wallet: state.wallet, player: state.player }); } function pruneDeck() { const seen = new Set(); state.deck.active = (state.deck.active || []).filter(id => isOwned(id) && byId[id] && !seen.has(baseOf(id)) && seen.add(baseOf(id))).slice(0, 5); state.deck.reserve = (state.deck.reserve || []).filter(id => isOwned(id) && byId[id] && !seen.has(baseOf(id)) && seen.add(baseOf(id))).slice(0, 5); saveDeck(); } async function callApi(path, body = {}) { if (!state.user) throw new Error('Inicia sesión primero.'); const token = await state.user.getIdToken(); let res; try { res = await fetch(`${apiBase()}/${path}`, { method: 'POST', mode: 'cors', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(body) }); } catch (err) { throw new Error('El backend no ha respondido. Despliega functions y prueba otra vez.'); } const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || 'Error de servidor.'); return data; } function toast(text, bad = false) { let el = document.getElementById('ccToast'); if (!el) { el = document.createElement('div'); el.id = 'ccToast'; Object.assign(el.style, { position:'fixed', right:'18px', bottom:'18px', zIndex:'1700', padding:'14px 16px', borderRadius:'16px', maxWidth:'520px', fontWeight:'800', boxShadow:'0 18px 45px rgba(0,0,0,.35)', transition:'all .2s ease' }); document.body.appendChild(el); } el.textContent = text; el.style.background = bad ? 'rgba(72,8,18,.96)' : 'rgba(20,58,30,.96)'; el.style.border = bad ? '1px solid rgba(255,141,152,.35)' : '1px solid rgba(145,243,161,.35)'; el.style.color = '#fff'; el.style.opacity='1'; clearTimeout(el._t); el._t=setTimeout(()=>el.style.opacity='0', 3200); } function modal(html) { const root = $('#ccModalRoot'); root.innerHTML = `
${html}
`; $('.cc-modal__close', root)?.addEventListener('click', closeModal); $('.cc-modal', root)?.addEventListener('click', e => { if (e.target.classList.contains('cc-modal')) closeModal(); }); } function closeModal() { $('#ccModalRoot').innerHTML = ''; } function renderWalletPanel() { const el = $('#ccWalletPanel'); if (!el) return; if (!state.user) { el.innerHTML = `
Invitado

Inicia sesión

Puedes ver el álbum, pero para guardar colección, abrir sobres y fusionar cartas necesitas sesión.

`; return; } const adminTools = isAdminUser() ? `
Herramientas admin de pruebas V8
✎ Editor personajes
Solo afecta a esta cuenta admin.
` : ''; const maGame = number(state.wallet.maGame); el.innerHTML = `
Conectado${esc(state.user.email || '')}
❤️ ${number(state.wallet.ma)}
MAJ de juego🪙 ${maGame}
Disponible en juegos${number(state.wallet.ma) + maGame}
MA gratis${number(state.wallet.maFree)}
MA de apoyo${number(state.wallet.maPaid)}
Únicas: ${uniqueOwnedCards()}
Sobres: ${number(state.player.stats?.packsOpened)}
${adminTools}`; bindAdminCardButtons(); } async function runAdminCardAction(action, extra = {}) { const labels = { resetSelf: 'resetear la colección de pruebas', clearEvolutions: 'limpiar evoluciones Plata/Oro', repairCollection: 'reparar/normalizar la colección', addTestCards: `añadir ${extra.qty || 5} copias de prueba`, addTestSkin: 'desbloquear una imagen/skin de prueba', clearTestSkins: 'quitar skins de prueba/desbloqueadas', }; const label = labels[action] || 'ejecutar la herramienta admin'; if (!confirm(`¿Seguro que quieres ${label}? Solo afecta a esta cuenta admin.`)) return; try { const data = await callApi('admin-card-maintenance', { action, ...extra }); normalizeState(data); clearDeck(); renderAll(); toast(data.message || 'Herramienta admin ejecutada.'); } catch (err) { toast(err.message || 'Error admin.', true); } } function bindAdminCardButtons() { $$('[data-admin-card-action]').forEach(btn => { if (btn.dataset.bound === '1') return; btn.dataset.bound = '1'; btn.addEventListener('click', () => runAdminCardAction(btn.dataset.adminCardAction, { baseId: btn.dataset.adminBaseId || '', cardId: btn.dataset.adminCardId || '', category: btn.dataset.adminCategory || 'Normal', qty: number(btn.dataset.adminQty || 5), skinId: btn.dataset.adminSkinId || '', })); }); } function renderStats() { const el = $('#ccStatsStrip'); if (!el) return; const ordinaryBases = [...new Set(cardsData.filter(c => EVOLUTION_CATEGORIES.includes(c.category)).map(c => baseOf(c)))]; const basesOwned = ordinaryBases.filter(baseHasAnyOwned).length; const silverOwned = ordinaryBases.filter(b => qtyByBaseCategory(b, 'Plata') > 0).length; const goldOwned = ordinaryBases.filter(b => qtyByBaseCategory(b, 'Oro') > 0).length; const evolvable = ordinaryBases.filter(b => baseEvolutionOptions(b).some(o => o.available >= o.cost)).length; const stats = [['Personajes', `${basesOwned}/${ordinaryBases.length}`, 'Álbum por personaje'], ['Copias totales', totalOwnedCards(), 'Incluye material de fusión'], ['Plata/Oro', `${silverOwned}/${goldOwned}`, 'Evoluciones obtenidas'], ['Fusionables', evolvable, 'Listos para mejorar']]; el.innerHTML = stats.map(([a,b,c]) => `
${esc(a)}
${esc(b)}
${esc(c)}
`).join(''); } function renderPacks() { const wrap = $('#ccPackGrid'); if (!wrap) return; wrap.innerHTML = PACKS.map(pack => `
${pack.enabled?'Disponible':'Preparado'}
${esc(pack.name)}
${esc(pack.subtitle)}
${pack.price} MA/MAJ

${esc(pack.pitch)}

`).join(''); $$('[data-pack-open]').forEach(btn => btn.addEventListener('click', () => openPackFlow(btn.dataset.packOpen))); } function renderMissions() { const wrap = $('#ccMissionCards'); if (!wrap) return; wrap.innerHTML = missionCatalog.map(m => `
${esc(m.mode)}Equipo ${m.teamSize}Reserva ${m.reserveSize}

${esc(m.name)}

${esc(m.description)}

${Object.values(m.categories).map(c=>`${esc(c.label)}`).join('')}
`).join(''); $$('[data-mission-play]').forEach(btn => btn.addEventListener('click', () => openMissionModal(btn.dataset.missionPlay))); } function cardSortScore(card) { return (RARITY_SCORE[card.rarity] || 0) * 100 + (VARIANT_SCORE[card.category] || 0) * 20 + totalCardValue(card); } function totalCardValue(card) { return sum(Object.values(card.day||{}).flatMap(o=>Object.values(o||{})).map(number)) + sum(Object.values(card.night||{}).flatMap(o=>Object.values(o||{})).map(number)); } function renderCollectionAdminTools() { const el = $('#ccCollectionAdminTools'); if (!el) return; if (!isAdminUser()) { el.innerHTML = ''; return; } el.innerHTML = `
Herramientas admin de pruebas Limpieza solo para esta cuenta. Úsalo para borrar errores de pruebas antiguas.
`; bindAdminCardButtons(); } function renderCollection() { const wrap = $('#ccCollectionGrid'); if (!wrap) return; const search = state.filters.search.trim().toLowerCase(); const ordinaryBases = [...new Set(cardsData.filter(c => EVOLUTION_CATEGORIES.includes(c.category)).map(c => baseOf(c)))]; let list = ordinaryBases.map(baseId => ({ baseId, card: highestOwnedOrdinaryCard(baseId, state.filters.owned === 'all') })).filter(x => x.card); // Las cartas especiales siguen existiendo como versiones con reglas propias, pero las evoluciones Normal/Plata/Oro se muestran agrupadas. const specialCards = cardsData.filter(c => c.category === 'Especial').map(c => ({ baseId: baseOf(c), card: c, special: true })); list = list.concat(specialCards.filter(x => state.filters.owned === 'all' || isOwned(x.card.id))); list = list.filter(({ baseId, card, special }) => { if (state.filters.owned === 'owned') { if (special) { if (!isOwned(card.id)) return false; } else if (!baseHasAnyOwned(baseId)) return false; } if (state.filters.rarity !== 'all' && card.rarity !== state.filters.rarity) return false; if (state.filters.variant !== 'all') { if (special) { if (card.category !== state.filters.variant) return false; } else if (state.filters.variant !== 'Normal' && qtyByBaseCategory(baseId, state.filters.variant) <= 0) return false; } if (search) { const groupText = (card.groups || []).join(' '); const hay = `${card.name} ${card.baseName} ${card.category} ${card.rarity} ${groupText} ${card.description || ''}`.toLowerCase(); if (!hay.includes(search)) return false; } return true; }).sort((a,b) => cardSortScore(b.card)-cardSortScore(a.card)); if (!list.length) { wrap.innerHTML = `
No hay cartas con esos filtros.
`; return; } wrap.innerHTML = list.map(({ baseId, card, special }) => { const qty = special ? ownedQty(card.id) : Math.max(ownedQty(card.id), sum(ordinaryCardsForBase(baseId).map(c => ownedQty(c.id)))); const playable = special ? card : bestPlayableCardForBase(baseId); const inDeck = playable && (state.deck.active.includes(playable.id) || state.deck.reserve.includes(playable.id)); const options = special ? [] : baseEvolutionOptions(baseId).filter(o => o.available >= o.cost); const evoButtons = options.map(o => ``).join(''); const adminButtons = isAdminUser() && !special ? `` : ''; const missing = state.filters.owned === 'all' && !special && !baseHasAnyOwned(baseId); return `
x${qty}
${esc(card.name)}
${esc((card.baseName || card.name).replace(/\s*\((Plata|Oro)\)\s*$/i,''))}${esc(card.rarity)}
${special ? esc(displayVariant(card)) : 'Personaje evolucionable'}
${renderGroupIcons(card.groups, 5)} ${special ? '' : evolutionCounterHtml(baseId)}
${esc(displayVariant(card))}${card.packEligible?'sobre':'fusión/evento'}
${evoButtons} ${adminButtons}
`; }).join(''); $$('[data-view-card]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); openCardModal(btn.dataset.viewCard); })); $$('[data-deck-card]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); toggleDeckCard(btn.dataset.deckCard); })); $$('[data-evolve-base]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); evolveBase(btn.dataset.evolveBase, btn.dataset.targetCategory); })); bindAdminCardButtons(); } function renderDeck() { const active = $('#ccActiveSlots'), reserve = $('#ccReserveSlots'); if (!active || !reserve) return; active.innerHTML = Array.from({ length: 5 }, (_, i) => renderSlot(state.deck.active[i], 'active', i)).join(''); reserve.innerHTML = Array.from({ length: 5 }, (_, i) => renderSlot(state.deck.reserve[i], 'reserve', i)).join(''); $$('[data-slot-remove]').forEach(btn => btn.addEventListener('click', () => removeFromDeck(btn.dataset.slotRemove, Number(btn.dataset.slotIndex)))); renderResourcePanel(); } function renderSlot(cardId, type, i) { if (!cardId || !byId[cardId]) return `
${type==='active'?'Activo':'Reserva'} ${i+1}
Hueco libre
`; const c = byId[cardId]; return `
${esc(c.name)}${esc(c.name)}
${esc(displayVariant(c))}
`; } function autoDeck(render=true) { const best = cardsData.filter(c => isOwned(c.id)).sort((a,b)=>cardSortScore(b)-cardSortScore(a)); const seen=new Set(); const chosen=[]; for (const c of best) { if (seen.has(baseOf(c))) continue; chosen.push(c.id); seen.add(baseOf(c)); } state.deck.active=chosen.slice(0,5); state.deck.reserve=chosen.slice(5,10); saveDeck(); if (render) renderAll(); } function clearDeck() { state.deck={active:[],reserve:[]}; saveDeck(); renderAll(); } function removeFromDeck(type, index) { state.deck[type].splice(index,1); saveDeck(); renderAll(); } function toggleDeckCard(cardId) { if (!isOwned(cardId)) return; if (state.deck.active.includes(cardId)) state.deck.active = state.deck.active.filter(id=>id!==cardId); else if (state.deck.reserve.includes(cardId)) state.deck.reserve = state.deck.reserve.filter(id=>id!==cardId); else { const base = baseOf(cardId); const exists = [...state.deck.active, ...state.deck.reserve].some(id => baseOf(id) === base); if (exists) { toast('No puedes repetir el mismo personaje base en el equipo.', true); return; } if (state.deck.active.length < 5) state.deck.active.push(cardId); else if (state.deck.reserve.length < 5) state.deck.reserve.push(cardId); else { toast('Equipo y reserva llenos.', true); return; } } saveDeck(); renderAll(); } function applyEffectToAccumulator(acc, effects={}, poolScale=1, label='', bonusScale=1) { if (!effects) return; acc.wildcardPool += number(effects.wildcardPool) * poolScale; acc.wildcardBonus += number(effects.wildcardBonus) * bonusScale; acc.groupPV += number(effects.groupPV) * poolScale; for (const [k,v] of Object.entries(effects.pools||{})) acc.pools[k] = number(acc.pools[k]) + number(v)*poolScale; for (const [k,v] of Object.entries(effects.bonuses||{})) acc.bonuses[k] = number(acc.bonuses[k]) + number(v)*bonusScale; for (const [k,v] of Object.entries(effects.autoSuccesses||{})) acc.autoSuccesses[k] = number(acc.autoSuccesses[k]) + number(v)*poolScale; if (effects.allSkills) acc.penaltyAll += number(effects.allSkills); if (label) acc.lines.push(label); } function calcResources(cards) { const active = cards || state.deck.active.map(id=>byId[id]).filter(Boolean); const acc = { pools:{}, bonuses:{}, autoSuccesses:{}, wildcardPool:0, wildcardBonus:0, groupPV:0, penaltyAll:0, pairByReceiver:{}, lines:[] }; const groupCounts={}; active.forEach(c => (c.groups||[]).forEach(g => groupCounts[norm(g)]=(groupCounts[norm(g)]||0)+1)); for (const rule of groupRules) { const count = groupCounts[norm(rule.group)] || 0; if (count >= number(rule.minMembers || 2)) applyEffectToAccumulator(acc, rule.effects, Math.max(1, count-1), `${rule.group}: ${count} miembros`, 1); } const bases = new Set(active.map(c=>baseOf(c)).map(slug)); for (const pr of pairRules) { if (!bases.has(slug(pr.receiver)) || !bases.has(slug(pr.related))) continue; const target = acc.pairByReceiver[slug(pr.receiver)] ||= { pools:{}, bonuses:{}, autoSuccesses:{}, wildcardPool:0, wildcardBonus:0, groupPV:0, penaltyAll:0, lines:[] }; applyEffectToAccumulator(target, pr.effects, 1, `${pr.raw || `${pr.receiver}-${pr.related}`} (${pr.type || 'Bonus'})`, 1); } return acc; } function resourceChipClass(kind) { return kind === 'warn' ? 'cc-chip cc-chip--warn' : kind === 'bad' ? 'cc-chip cc-chip--bad' : 'cc-chip cc-chip--good'; } function renderResourcePanel() { const el = $('#ccResourcePanel'); if (!el) return; const active = state.deck.active.map(id=>byId[id]).filter(Boolean); const res = calcResources(active); const bonusLines = Object.entries(res.bonuses).map(([k,v])=>`+${v} ${esc(k)}`).join(''); const poolLines = Object.entries(res.pools).map(([k,v])=>`Pull ${esc(k)}: ${v}`).join(''); const autoLines = Object.entries(res.autoSuccesses).map(([k,v])=>`Éxito ${esc(k)}: ${v}`).join(''); const pairCount = Object.values(res.pairByReceiver).reduce((a,b)=>a+b.lines.length,0); const groupLines = res.lines.length ? res.lines.map(line => `${esc(line)}`).join('') : 'Sin bonus de grupo activo.'; el.innerHTML = `

Ventajas y recursos del equipo

Grupos activos
${groupLines}
Bonos
${bonusLines || 'Sin bonos directos.'}
Pulls / éxitos
${poolLines || ''}${autoLines || ''}${(!poolLines&&!autoLines)?'Sin pulls.':''}
PV grupo

${res.groupPV}

Afinidades individuales

${pairCount} activas

`; } async function evolveBase(baseId, targetCategory) { const sourceCategory = targetCategory === 'Oro' ? 'Plata' : 'Normal'; const source = cardByBaseCategory(baseId, sourceCategory); const target = cardByBaseCategory(baseId, targetCategory); if (!source || !target) return toast('Esta evolución aún no existe en el catálogo.', true); const cost = number(target.upgradeCost || 5); if (qtyByBaseCategory(baseId, sourceCategory) < cost) return toast(`Necesitas ${cost} copias ${sourceCategory} para fusionar.`, true); try { const data = await callApi('evolve-card', { baseId, sourceCategory, targetCategory }); normalizeState(data); pruneDeck(); renderAll(); toast(data.message || `Carta fusionada a ${targetCategory}.`); } catch(err) { toast(err.message, true); } } async function evolveCard(cardId) { const card = byId[cardId]; if (!card) return; const target = findEvolutionTarget(card); if (!target) return toast('Esta carta aún no tiene evolución creada en el catálogo.', true); return evolveBase(baseOf(card), target.category); } function renderAll() { renderWalletPanel(); renderStats(); renderPacks(); renderMissions(); renderDeck(); renderCollectionAdminTools(); renderCollection(); } function bindStaticEvents() { // Trampa invisible + bloqueo por readonly contra autofill de Chrome/Edge. const search = $('#ccSearchInput'); if (search) { search.value = ''; search.setAttribute('autocomplete', 'new-password'); search.setAttribute('autocorrect', 'off'); search.setAttribute('autocapitalize', 'off'); search.setAttribute('spellcheck', 'false'); search.setAttribute('data-lpignore', 'true'); search.setAttribute('data-form-type', 'other'); search.setAttribute('name', 'cc_no_autofill_' + Date.now()); search.setAttribute('readonly', 'readonly'); state.filters.search = ''; const acceptManual = () => { ccSearchUserTouched = true; enableSearchManualInput(); }; ['keydown', 'paste', 'drop', 'compositionstart'].forEach(ev => search.addEventListener(ev, acceptManual, { passive: true })); search.addEventListener('focus', () => { enableSearchManualInput(); if (!ccSearchUserTouched) hardResetSearch('focus'); }); search.addEventListener('mousedown', () => { enableSearchManualInput(); if (!ccSearchUserTouched) hardResetSearch('mousedown'); }); search.addEventListener('input', e => { const withinProtection = Date.now() < ccSearchProtectionUntil; if (!ccSearchUserTouched && withinProtection) { e.target.value = ''; state.filters.search = ''; renderCollection(); return; } state.filters.search = e.target.value || ''; renderCollection(); }); [0, 80, 250, 700, 1400, 2600].forEach(ms => setTimeout(() => { if (!ccSearchUserTouched) { hardResetSearch('startup'); renderCollection(); } }, ms)); } $('#ccScrollCollectionBtn')?.addEventListener('click', () => $('#ccCollectionSection')?.scrollIntoView({ behavior:'smooth' })); $('#ccClaimStarterBtn')?.addEventListener('click', async () => { try { const data = await callApi('grant-free-fayenne-card'); normalizeState(data); renderAll(); toast(data.message || 'Fayenne reclamada.'); } catch(err) { toast(err.message, true); } }); $('#ccOpenStandardBtn')?.addEventListener('click', () => openPackFlow('standard')); $('#ccRarityFilter')?.addEventListener('change', e => { state.filters.rarity=e.target.value; renderCollection(); }); $('#ccVariantFilter')?.addEventListener('change', e => { state.filters.variant=e.target.value; renderCollection(); }); $('#ccOwnedFilter')?.addEventListener('change', e => { state.filters.owned=e.target.value; renderCollection(); }); $('#ccAutoDeckBtn')?.addEventListener('click', () => autoDeck(true)); $('#ccClearDeckBtn')?.addEventListener('click', clearDeck); } function renderStatBlocks(sections={}) { return Object.entries(sections).map(([name, values]) => `

${esc(cap(name))}

${Object.entries(values||{}).filter(([k])=>!k.startsWith('_')).map(([k,v])=>`
${esc(k)}${number(v)}
`).join('')}
`).join(''); } function groupRulesForCard(card) { return (card.groups || []).flatMap(g => groupRules.filter(r => norm(r.group) === norm(g)).map(r => ({ group:g, rule:r }))); } function pairRulesForCard(card) { const base = slug(baseOf(card)); return pairRules.filter(p => slug(p.receiver) === base); } function effectSummary(effects) { const out = []; for (const [k,v] of Object.entries(effects?.bonuses || {})) out.push(`+${v} ${k}`); for (const [k,v] of Object.entries(effects?.pools || {})) out.push(`Pull ${k}: ${v}`); for (const [k,v] of Object.entries(effects?.autoSuccesses || {})) out.push(`Éxito ${k}: ${v}`); if (effects?.wildcardPool) out.push(`Comodín: ${effects.wildcardPool}`); if (effects?.groupPV) out.push(`PV grupo: ${effects.groupPV}`); if (effects?.allSkills) out.push(`${effects.allSkills} a todas`); return out.join(' · ') || 'Sin efecto definido'; } function openCardModal(cardId, startTab='day') { const card = byId[cardId]; if (!card) return; modal(`
${esc(card.name)}
${esc(card.rarity)}${esc(displayVariant(card))}x${ownedQty(card.id)}

${esc(card.name)}

${esc(card.description||'')}
`); const panels = { day: () => renderStatBlocks({ Arte: card.day?.arte, Persuasión: card.day?.persuasion, Conocimientos: card.day?.conocimientos, Artesanía: card.day?.artesania }), night: () => renderStatBlocks({ Base: card.night?.base, Combate: card.night?.combate, Magia: card.night?.magia, Fe: card.night?.fe, Subterfugio: card.night?.subterfugio }), bio: () => `

${esc(card.bio || card.description || 'Sin bio todavía.')}

Grupos

${renderGroupIcons(card.groups, 30)}
`, special: () => renderSpecialPanel(card), skins: () => renderSkinPanel(card) }; function setTab(tab) { $$('#ccModalRoot [data-tab]').forEach(btn => btn.classList.toggle('is-active', btn.dataset.tab===tab)); $('#ccTabContent').innerHTML = panels[tab](); bindSkinButtons(); } $$('#ccModalRoot [data-tab]').forEach(btn => btn.addEventListener('click', () => setTab(btn.dataset.tab))); setTab(startTab); } function renderSpecialPanel(card) { const groupRows = groupRulesForCard(card).map(x => `
  • ${esc(x.group)}: ${esc(effectSummary(x.rule.effects))}
  • `).join('') || '
  • Sin bonus de grupo directo.
  • '; const pairRows = pairRulesForCard(card).slice(0,80).map(p => `
  • ${esc(p.raw || p.related)}: ${esc(effectSummary(p.effects))}
  • `).join('') || '
  • Sin afinidades individuales configuradas.
  • '; return `

    ${esc(card.special?.name || 'Especial')}

    ${esc(card.special?.description || card.special?.text || 'Pendiente de programar.')}

    Bonus por grupos de este personaje

    Afinidades y penalizaciones

    `; } function renderSkinPanel(card) { const allSkins = skinsByBase[card.baseId] || []; const normalUnlocked = []; const evolutionUnlocked = []; const lockedNormal = []; for (const s of allSkins) { const evo = skinRequiresEvolution(s); const owned = isSkinOwned(s.id); if (evo) { if (owned) evolutionUnlocked.push(s); } else if (owned) { normalUnlocked.push(s); } else { lockedNormal.push(s); } } const shown = [...normalUnlocked, ...evolutionUnlocked]; const lockedPreview = lockedNormal.filter(s => !s.free && !s.default).slice(0, 12); if (!shown.length && !lockedPreview.length) return `
    Este personaje aún no tiene variantes de imagen disponibles.
    `; const cardHtml = (s, owned) => { const selected = owned && (state.player.selectedSkins?.[card.baseId]===s.id || (!state.player.selectedSkins?.[card.baseId] && s.default)); const reason = skinRequiresEvolution(s) ? `Evolución ${esc(s.unlockCategory || '')}` : (s.free || s.default ? 'Base' : 'Sobre/evento'); const adminBtn = isAdminUser() && !owned && !skinRequiresEvolution(s) ? `` : ''; return `
    ${esc(s.label)}${esc(s.label)}${owned?'Disponible':`Bloqueada · ${reason}`}${owned?``:adminBtn}
    `; }; return `

    Imagen preferida

    La imagen base está disponible al tener el personaje. Las imágenes Plata/Oro aparecen cuando tienes esa evolución. Los vestuarios normales se desbloquean por sobres, eventos, recompensas o admin.

    ${shown.length?`

    Disponibles

    ${shown.map(s => cardHtml(s, true)).join('')}
    `:''}${lockedPreview.length?`

    Por conseguir

    ${lockedPreview.map(s => cardHtml(s, false)).join('')}
    `:''}
    `; } function bindSkinButtons() { $$('[data-select-skin]').forEach(btn => btn.addEventListener('click', async () => { try { const data = await callApi('select-card-skin', { baseId: btn.dataset.baseId, skinId: btn.dataset.selectSkin }); normalizeState(data); renderAll(); openCardModal(btn.dataset.baseId, 'skins'); toast('Imagen preferida actualizada.'); } catch(err) { toast(err.message, true); } })); bindAdminCardButtons(); } async function openPackFlow(packId) { const pack = PACKS.find(p => p.id === packId && p.enabled); if (!pack) return toast('Sobre no disponible.', true); if (!state.user) return toast('Inicia sesión para abrir sobres.', true); modal(`
    ${esc(pack.name)}
    ${pack.price} MA
    `); $('#ccDoOpenPackBtn')?.addEventListener('click', async () => { $('#ccDoOpenPackBtn').disabled=true; $('#ccPackOpenArea').innerHTML='
    Abriendo sobre...
    '; try { const data = await callApi('buy-card-pack', { packId }); normalizeState(data); renderAll(); renderPackReveal(data.drawn || [], data.drawnDetails || []); } catch(err) { $('#ccDoOpenPackBtn').disabled=false; toast(err.message, true); } }); } function renderPackReveal(ids, details=[]) { const cards = ids.map(id=>byId[id]).filter(Boolean); const area = $('#ccPackOpenArea'); if (!area) return; const detailByCardIndex = details || []; area.innerHTML = `
    ${cards.map((c,i)=>{ const skinId = detailByCardIndex[i]?.skinId || ''; return `
    ¿?
    ${esc(c.name)}
    ${esc(c.name)}${esc(c.rarity)} · ${esc(displayVariant(c))}${skinId && skinsById[skinId] ? ` · ${esc(skinsById[skinId].label)}` : ''}
    `;}).join('')}
    `; $$('[data-reveal-index]').forEach(el => el.addEventListener('click', () => el.classList.add('is-revealed'))); $('#ccRevealAllBtn')?.addEventListener('click', () => $$('[data-reveal-index]').forEach(el=>el.classList.add('is-revealed'))); $('#ccClosePackBtn')?.addEventListener('click', closeModal); } function weightedPick(obj) { const entries=Object.entries(obj||{}).filter(([,w])=>number(w)>0); const total=sum(entries.map(([,w])=>number(w))); let roll=Math.random()*total; for (const [k,w] of entries) { roll-=number(w); if (roll<=0) return k; } return entries[0]?.[0]; } function makeChallenges(mission) { const cats = Object.entries(mission.categories); const catWeights=Object.fromEntries(cats.map(([k,v])=>[k,v.weight||1])); return Array.from({length: mission.challengeCount || 4}, (_,i)=>{ const catKey=weightedPick(catWeights); const cat=mission.categories[catKey]; return { id:`ch${i}`, catKey, label:cat.label, revealed:false, resolved:false, skill:null, target:null }; }); } function revealChallenge(mission, ch) { const cat=mission.categories[ch.catKey]; ch.skill = weightedPick(cat.skills); ch.target = rnd(cat.targetMin, cat.targetMax); ch.revealed=true; } function getCardSkill(card, catKey, skill) { return number(card.day?.[catKey]?.[skill] ?? card.night?.[catKey]?.[skill] ?? 0); } function calcEffectiveSkill(card, catKey, skill, runtime, spend=0) { const base = getCardSkill(card,catKey,skill); const team = runtime.resources; const pair = team.pairByReceiver[slug(card.baseId)] || { bonuses:{}, wildcardBonus:0, penaltyAll:0 }; const key = `${catKey}.${skill}`; const direct = number(team.bonuses[key]) + number(pair.bonuses?.[key]); const wildcardBonus = number(team.wildcardBonus) + number(pair.wildcardBonus); const penalty = number(pair.penaltyAll); return Math.max(0, base + direct + wildcardBonus + penalty + number(spend)); } function autoSuccessKey(rt) { const ch = rt.selectedChallenge; if (!ch) return ''; const exact = `${ch.catKey}.${ch.skill}`; const exactLeft = number(rt.resources.autoSuccesses[exact]) - number(rt.spent.autoSuccesses?.[exact]); if (exactLeft > 0) return exact; const catLeft = number(rt.resources.autoSuccesses[ch.catKey]) - number(rt.spent.autoSuccesses?.[ch.catKey]); if (catLeft > 0) return ch.catKey; return ''; } function openMissionModal(missionId) { const mission = missionCatalog.find(m=>m.id===missionId); if (!mission) return; pruneDeck(); const active = state.deck.active.map(id=>byId[id]).filter(Boolean).slice(0,mission.teamSize); const reserve = state.deck.reserve.map(id=>byId[id]).filter(Boolean).slice(0,mission.reserveSize); const resources = calcResources(active); const runtime = { mission, active: active.map(c=>({...c, hp:number(c.night?.base?.PV||1), fallen:false})), reserve:[...reserve], challenges:makeChallenges(mission), selectedChallenge:null, successes:0, failures:0, log:[], resources, groupPV:number(resources.groupPV), maxGroupPV:number(resources.groupPV), spent:{ wildcardPool:0, pools:{}, autoSuccesses:{} }, enemy: mission.enemy ? {...mission.enemy} : null, weakness:false }; modal(`
    `); renderMissionRuntime(runtime); } function renderMissionRuntime(rt) { const root = $('#ccMissionRuntime'); if (!root) return; const m=rt.mission; root.innerHTML = `

    ${esc(m.name)}

    ${esc(m.description)}

    ${m.mode==='Día'?`Éxitos ${rt.successes}/${m.neededSuccesses}`:`${esc(rt.enemy?.name||'')} PV ${number(rt.enemy?.pv)}`}
    ${rt.enemy?`
    ${esc(rt.enemy.name)}Daño ${rt.enemy.damage}${rt.weakness?'Punto débil descubierto':'Último PV bloqueado hasta Descubrir'}
    `:''}
    ${rt.challenges.map(ch=>renderChallengeCard(ch, rt.selectedChallenge?.id===ch.id)).join('')}
    ${renderResolutionPanel(rt)}

    Personajes activos

    ${rt.active.map((c,i)=>renderMissionCharacter(c,i,rt)).join('')}

    Reserva

    ${rt.reserve.map(c=>`
    ${esc(c.name)} · ${esc(displayVariant(c))}
    `).join('') || 'Sin reserva.'}

    Recursos disponibles

    ${renderRuntimeResources(rt)}
    ${rt.log.slice(-8).map(x=>`
    ${esc(x)}
    `).join('')}
    `; $$('.cc-board-challenge').forEach(el => el.addEventListener('click', () => { const ch=rt.challenges.find(x=>x.id===el.dataset.challengeId); if (!ch || ch.resolved) return; if (!ch.revealed) revealChallenge(rt.mission,ch); rt.selectedChallenge=ch; renderMissionRuntime(rt); })); $$('[data-mission-char]').forEach(btn => btn.addEventListener('click', () => resolveWithCharacter(rt, Number(btn.dataset.missionChar)))); $$('[data-use-pool]').forEach(btn => btn.addEventListener('click', () => { const k=btn.dataset.usePool; rt.spent.pools[k]=number(rt.spent.pools[k])+1; renderMissionRuntime(rt); })); $$('[data-use-wildcard]').forEach(btn => btn.addEventListener('click', () => { rt.spent.wildcardPool++; renderMissionRuntime(rt); })); $$('[data-use-auto-success]').forEach(btn => btn.addEventListener('click', () => resolveWithAutoSuccess(rt, btn.dataset.useAutoSuccess))); } function renderChallengeCard(ch, selected) { return ``; } function renderMissionCharacter(c,i,rt) { const dead=c.fallen||c.hp<=0; const ch=rt.selectedChallenge; const preview=ch?.revealed&&!ch.resolved&&!dead?`
    ${getCardSkill(c,ch.catKey,ch.skill)} base
    `:''; return `
    ${esc(c.name)}${esc(c.name)}${esc(displayVariant(c))}PV ${c.hp}${preview}
    `; } function renderRuntimeResources(rt) { const ch=rt.selectedChallenge; const wildcardLeft=number(rt.resources.wildcardPool)-number(rt.spent.wildcardPool); const poolLeft = ch ? number(rt.resources.pools[ch.catKey]) - number(rt.spent.pools[ch.catKey]) : 0; const autoKey = autoSuccessKey(rt); const autoLeft = autoKey ? number(rt.resources.autoSuccesses[autoKey]) - number(rt.spent.autoSuccesses?.[autoKey]) : 0; return `
    PV grupo ${rt.groupPV}/${rt.maxGroupPV}${Object.entries(rt.resources.bonuses).map(([k,v])=>`+${v} ${esc(k)}`).join('')}${Object.entries(rt.resources.pools).map(([k,v])=>`Pull ${esc(k)}: ${v-number(rt.spent.pools[k])}/${v}`).join('')}${Object.entries(rt.resources.autoSuccesses).map(([k,v])=>`Éxito ${esc(k)}: ${v-number(rt.spent.autoSuccesses?.[k])}/${v}`).join('')}${rt.resources.wildcardPool?`Comodín: ${wildcardLeft}/${rt.resources.wildcardPool}`:''}
    ${ch?.revealed&&!ch.resolved?``:''}`; } function renderResolutionPanel(rt) { const ch=rt.selectedChallenge; if (!ch) return `
    Elige una de las 4 cartas de reto. Solo conoces la categoría hasta revelarla.
    `; if (ch.resolved) return `
    Reto resuelto. Elige otro.
    `; if (!ch.revealed) return `
    Reto boca abajo.
    `; const spend=number(rt.spent.wildcardPool)+number(rt.spent.pools[ch.catKey]); return `
    ${esc(ch.label)} → ${esc(ch.skill)} dificultad ${ch.target}
    Elige personaje o gasta un éxito automático si lo tienes. Gastado para este reto: +${spend}
    `; } function resolveWithAutoSuccess(rt, key) { const ch=rt.selectedChallenge; if (!ch || ch.resolved) return; rt.spent.autoSuccesses[key] = number(rt.spent.autoSuccesses[key]) + 1; if (rt.mission.mode === 'Día') { rt.successes++; rt.log.push(`Éxito automático usado en ${ch.skill}.`); } else { if (ch.catKey==='subterfugio' && ch.skill==='Descubrir') { rt.weakness=true; rt.log.push('Éxito automático: punto débil descubierto.'); } else if (ch.skill==='Ofensivo' && rt.enemy) { rt.enemy.pv=Math.max(0, rt.enemy.pv-1); rt.log.push('Éxito automático ofensivo: 1 daño.'); } else rt.log.push(`Éxito automático usado en ${ch.skill}.`); } ch.resolved=true; rt.spent={wildcardPool:0,pools:{},autoSuccesses:rt.spent.autoSuccesses}; renderMissionRuntime(rt); } function resolveWithCharacter(rt, idx) { const ch=rt.selectedChallenge; const card=rt.active[idx]; if (!ch || !card || ch.resolved || card.fallen) return; const spend=number(rt.spent.wildcardPool)+number(rt.spent.pools[ch.catKey]); const value=calcEffectiveSkill(card,ch.catKey,ch.skill,rt,spend); const target=number(ch.target); let result=''; if (rt.mission.mode === 'Día') { if (value > target) { rt.successes++; result=`${card.name} supera ${ch.skill} (${value} > ${target}) y sigue en juego.`; } else if (value === target) { rt.successes++; result=`${card.name} empata ${ch.skill} (${value} = ${target}), consigue éxito pero cae.`; dropCharacter(rt, idx); } else { rt.failures++; result=`${card.name} falla ${ch.skill} (${value} < ${target}) y cae.`; dropCharacter(rt, idx); } } else { resolveNight(rt, idx, card, ch, value, target); result = rt.log.pop() || `${card.name} resuelve ${ch.skill}.`; } ch.resolved=true; rt.spent={wildcardPool:0,pools:{},autoSuccesses:{}}; rt.log.push(result); rt.resources=calcResources(rt.active.filter(c=>!c.fallen)); rt.maxGroupPV=Math.max(rt.maxGroupPV, number(rt.resources.groupPV)); if (rt.mission.mode==='Día' && rt.successes>=rt.mission.neededSuccesses) rt.log.push('Misión superada.'); if (rt.enemy && rt.enemy.pv<=0) rt.log.push('Enemigo derrotado.'); renderMissionRuntime(rt); } function resolveNight(rt, idx, card, ch, value, target) { const skill=ch.skill; if (ch.catKey==='subterfugio' && skill==='Descubrir' && value>=target) { rt.weakness=true; rt.log.push(`${card.name} descubre el punto débil (${value} ≥ ${target}).`); return; } if (skill==='Sanación' && value>=target) { if (rt.groupPV < rt.maxGroupPV) { rt.groupPV++; rt.log.push(`${card.name} restaura 1 PV de grupo.`); } else { const wounded=rt.active.find(c=>!c.fallen && c.hp=target) { let dmg=number(card.night?.base?.['Daño combate'] || 1); if (rt.enemy?.requiresWeakness && !rt.weakness && rt.enemy.pv-dmg<=0) { rt.enemy.pv=1; rt.log.push(`${card.name} hiere al enemigo, pero no puede rematarlo sin Descubrir.`); } else { rt.enemy.pv=Math.max(0, rt.enemy.pv-dmg); rt.log.push(`${card.name} causa ${dmg} daño (${value} ≥ ${target}).`); } } else { damageCharacter(rt, idx, number(rt.enemy?.damage||1)); rt.log.push(`${card.name} falla el ataque y recibe daño.`); } } else { if (value>=target) rt.log.push(`${card.name} evita el peligro (${value} ≥ ${target}).`); else { damageCharacter(rt, idx, number(rt.enemy?.damage||1)); rt.log.push(`${card.name} no supera la defensa y recibe daño.`); } } } function damageCharacter(rt, idx, dmg) { if (rt.groupPV > 0) { const used=Math.min(rt.groupPV,dmg); rt.groupPV-=used; dmg-=used; rt.log.push(`El PV de grupo absorbe ${used} daño.`); } if (dmg<=0) return; const c=rt.active[idx]; c.hp-=dmg; if (c.hp<=0) dropCharacter(rt, idx); } function dropCharacter(rt, idx) { const old=rt.active[idx]; old.fallen=true; const replacement=rt.reserve.shift(); if (replacement) rt.active[idx]={...replacement,hp:number(replacement.night?.base?.PV||1),fallen:false}; } async function loadGameState() { if (!state.user) return; try { const data = await callApi('get-card-game-state'); normalizeState(data); renderAll(); } catch(err) { toast(err.message, true); } } function loadGuestFromCache() { const cache=loadCache(); if (cache?.player) normalizeState(cache); } bindStaticEvents(); loadGuestFromCache(); renderAll(); onAuthStateChanged(auth, async user => { state.user=user||null; if (!user) { renderAll(); return; } await loadGameState(); });